3. Creating a Custom Job Definition
With our user interface largely complete, our next
step is to define a custom job that will compile all documents in our
document set and send the compiled output to Word Automation Services
for conversion to PDF.
In Visual Studio, add a new class named DocumentCombinerJob.cs. Add the following code to the file:
public class DocumentCombinerJob : SPJobDefinition
{
[Persisted]
private Guid _siteId;
[Persisted]
private Guid _webId;
[Persisted]
private Guid _folderId;
[Persisted]
private Guid _proxyId;
public DocumentCombinerJob()
: base()
{
}
public DocumentCombinerJob(SPListItem documentSet)
: base("Combine Documents" + Guid.NewGuid().ToString(),
SPFarm.Local.TimerService, null, SPJobLockType.None)
{
_siteId = documentSet.Web.Site.ID;
_webId = documentSet.Web.ID;
_folderId = documentSet.Folder.UniqueId;
_proxyId = SPServiceContext.Current.GetDefaultProxy(
typeof(WordServiceApplicationProxy)).Id;
Title = "Combine Documents - " + documentSet.Folder.Url;
}
protected override bool HasAdditionalUpdateAccess()
{
return true;
}
}
Developers
familiar with SharePoint 2007 should notice a few interesting elements
in this code snippet. First, check out the HasAdditionalUpdateAccess
override. In previous versions of SharePoint, only farm administrators
could create jobs. This greatly restricted their usefulness for
offloading ad hoc tasks. With SharePoint 2010, where the
HasAdditionalUpdateAccess method returns true, any user can create a
job.
Also notice that when we’re creating a job, the job
can be associated with either a service or an application pool. These
associations are primarily for administrative purposes since most jobs
run via the SPTimerV4 service. In our example, we’re associating our
custom job with the TimerService.
The final thing to notice is that job definitions are
serialized when a job is created. As a result, not all types of objects
can be defined as properties. For example, the SPListItem isn’t
serializable and therefore can’t be stored as a property. To get around
this problem, we’re storing a number of identifiers that can be used to
recover a reference to the appropriate SPListItem object when the job is
deserialized.
4. Combine Documents Using OpenXML
Before we can make use of OpenXML, we need to add a reference to the OpenXML SDK binaries:
Download and install the OpenXML SDK; then, in Visual Studio, add a reference to the DocumentFormat.OpenXML assembly.
Add a reference to the WindowsBase assembly.
To
prevent any confusion between similarly named objects within the
OpenXML SDK, add the following Using statement to the
DocumentCombinerJob.cs file:
using Word = DocumentFormat.OpenXml.Wordprocessing;
In the DocumentCombinerJob.cs file, add the following code:
public override void Execute(Guid targetInstanceId)
{
using (SPSite site = new SPSite(_siteId))
{
using (SPWeb web = site.OpenWeb(_webId))
{
SPFolder folder = web.GetFolder(_folderId);
SPListItem documentSet = folder.Item;
SPFile output = CombineDocuments(web, folder, documentSet);
ConvertOutput(site, web, output);
}
}
}
private SPFile CombineDocuments(SPWeb web, SPFolder folder,
SPListItem documentSet)
{
char[] splitter = { '/' };
string[] folderName = folder.Name.Split(splitter);
string templateUrl = documentSet.GetFormattedValue("TemplateUrl1");
SPFile template = web.GetFile(templateUrl);
byte[] byteArray = template.OpenBinary();
using (MemoryStream mem = new MemoryStream())
{
mem.Write(byteArray, 0, (int)byteArray.Length);
using (WordprocessingDocument myDoc =
WordprocessingDocument.Open(mem, true))
{
MainDocumentPart mainPart = myDoc.MainDocumentPart;
foreach (Word.SdtElement sdt in
mainPart.Document.Descendants<Word.SdtElement>().ToList())
{
Word.SdtAlias alias =
sdt.Descendants<Word.SdtAlias>().FirstOrDefault();
if (alias != null)
{
string sdtTitle = alias.Val.Value;
if (sdtTitle == "MergePlaceholder")
{
foreach (SPFile docFile in folder.Files)
{
if (docFile.Name.EndsWith(".docx"))
{
if (docFile.Name != "temp.docx")
{
InsertDocument(mainPart, sdt, docFile);
Word.PageBreakBefore pb = new Word.PageBreakBefore();
sdt.Parent.InsertAfter(pb, sdt);
}
}
}
sdt.Remove();
}
}
}
}
SPFile temp = folder.Files.Add("temp.docx", mem, true);
return temp;
}
}
protected int id = 1;
void InsertDocument(MainDocumentPart mainPart, Word.SdtElement sdt,
SPFile filename)
{
string altChunkId = "AIFId" + id;
id++;
byte[] byteArray = filename.OpenBinary();
AlternativeFormatImportPart chunk = mainPart.AddAlternativeFormatImportPart(
AlternativeFormatImportPartType.WordprocessingML, altChunkId);
using (MemoryStream mem = new MemoryStream())
{
mem.Write(byteArray, 0, (int)byteArray.Length);
mem.Seek(0, SeekOrigin.Begin);
chunk.FeedData(mem);
}
Word.AltChunk altChunk = new Word.AltChunk();
altChunk.Id = altChunkId;
OpenXmlElement parent = sdt.Parent.Parent;
parent.InsertAfter(altChunk, sdt.Parent);
}
private void ConvertOutput(SPSite site, SPWeb web, SPFile output)
{
throw new NotImplementedException();
}
In
this code snippet, the CombineDocuments method loads a Microsoft Word
format template. The code then searches for all content controls within
the document, and where the content control has a title of
MergePlaceholder, the contents of all files with a .docx extension
within the document set are merged into the template. The merge process
makes use of the AlternativeFormatImportPart control to merge contents.
This control inserts a binary copy of data into the template at a
specific position. When the completed document is rendered in a client
application, the merge is performed dynamically each time the document
is opened.
5. Converting an OpenXML Document to an Alternative Format
Before we can make use of Word Automation Services in our application, we need to add a reference to the appropriate assembly:
In
Visual Studio, add a reference to Microsoft.Office.Word.Server.dll. At
the time of writing, this appears in the Add Reference dialog as one of
two components named Microsoft Office 2010 component; this problem may
be resolved in the final release.
Update the ConvertOutput method in DocumentTimerJob.cs as follows:
private void ConvertOutput(SPSite site, SPWeb web, SPFile output)
{
ConversionJob convJob = new ConversionJob(_proxyId);
convJob.Name = "Document Assembly";
convJob.UserToken = web.CurrentUser.UserToken;
convJob.Settings.UpdateFields = true;
convJob.Settings.OutputFormat = SaveFormat.PDF;
convJob.Settings.OutputSaveBehavior = SaveBehavior.AlwaysOverwrite;
string webUrl = web.Url + "/";
convJob.AddFile(webUrl + output.Url, webUrl + output.Url.Replace(".docx", ".pdf"));
convJob.Start();
Guid jobId = convJob.JobId;
ConversionJobStatus status = new ConversionJobStatus(_proxyId, jobId, null);
while (status.Count != (status.Succeeded + status.Failed))
{
Thread.Sleep(3000);
status.Refresh();
}
if (status.Failed == status.Count)
{
throw new InvalidOperationException();
}
With our custom job definition
completed, we can change the implementation in our user interface to
create a new instance of the job.
In SalesProposalWebPartUserControl.ascx.cs, change the StartCompilation_Click method as follows:
protected void StartCompilation_Click(object sender, EventArgs e)
{
SPListItem current = SPContext.Current.ListItem;
current["JobId"] = string.Empty;
current.Update();
DocumentCombinerJob job = new DocumentCombinerJob(current);
job.Update();
job.RunNow();
current["JobId"] = job.Id;
current.Update();
RedrawUI();
}
We’ve now completed the code required to implement
our demonstration scenario. Deploy the project by selecting Deploy
SalesProposalApplication from the Build menu.